Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 54de21e265bc7bee0e0c5d36c52484f9fd36fbc0


Parents : 15f60a1
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T02:28:39-05:00

feat(tests): add frontend test coverage with new map components, geodesy functions, and audio handling tests

Changes
Diff

diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index 6f562442..96b73d33 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -261,10 +261,53 @@ describe("CallPage.vue", () => {
expect(stop).toHaveBeenCalled();
});
+ it("onToggleWebAudio enabling without active call skips microphone preflight", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.config = { telephone_web_audio_enabled: false };
+ wrapper.vm.activeCall = null;
+ const permit = vi.spyOn(wrapper.vm, "requestAudioPermission").mockResolvedValue(true);
+ const patch = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined);
+ await wrapper.vm.onToggleWebAudio(true);
+ expect(permit).not.toHaveBeenCalled();
+ expect(patch).toHaveBeenCalledWith({ telephone_web_audio_enabled: true });
+ });
+
+ it("onToggleWebAudio enabling during active call runs microphone preflight", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.config = { telephone_web_audio_enabled: false };
+ wrapper.vm.activeCall = { status: 6 };
+ const permit = vi.spyOn(wrapper.vm, "requestAudioPermission").mockResolvedValue(true);
+ const patch = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined);
+ const start = vi.spyOn(wrapper.vm, "startWebAudio").mockResolvedValue(undefined);
+ await wrapper.vm.onToggleWebAudio(true);
+ expect(permit).toHaveBeenCalled();
+ expect(patch).toHaveBeenCalledWith({ telephone_web_audio_enabled: true });
+ expect(start).toHaveBeenCalled();
+ });
+
+ it("getUserMediaWithMicFallback clears stale device id and retries wide open", async () => {
+ const wrapper = mountCallPage();
+ await flushPromises();
+ wrapper.vm.selectedAudioInputId = "gone";
+ wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "gone" }];
+ const err = new Error("over");
+ err.name = "OverconstrainedError";
+ const fakeStream = { getTracks: () => [{ stop: vi.fn() }] };
+ const getUserMedia = vi.fn().mockRejectedValueOnce(err).mockResolvedValueOnce(fakeStream);
+ const mediaDevices = { getUserMedia, enumerateDevices: vi.fn().mockResolvedValue([]) };
+ const stream = await wrapper.vm.getUserMediaWithMicFallback(mediaDevices);
+ expect(getUserMedia).toHaveBeenCalledTimes(2);
+ expect(wrapper.vm.selectedAudioInputId).toBeNull();
+ expect(stream).toBe(fakeStream);
+ });
+
it("startWebAudio disables bridge when media devices API is missing", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.config = { telephone_web_audio_enabled: true };
+ wrapper.vm.activeCall = { status: 6 };
const updateConfig = vi.spyOn(wrapper.vm, "updateConfig").mockResolvedValue(undefined);
const stopWebAudio = vi.spyOn(wrapper.vm, "stopWebAudio");
const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
@@ -307,7 +350,7 @@ describe("CallPage.vue", () => {
}
});
- it("refreshAudioDevices clears stale devices when media devices API is missing", async () => {
+ it("refreshAudioDevices uses default placeholders when media devices API is missing", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.audioInputDevices = [{ kind: "audioinput", deviceId: "old-in" }];
@@ -320,8 +363,22 @@ describe("CallPage.vue", () => {
try {
await wrapper.vm.refreshAudioDevices();
- expect(wrapper.vm.audioInputDevices).toEqual([]);
- expect(wrapper.vm.audioOutputDevices).toEqual([]);
+ expect(wrapper.vm.audioInputDevices).toEqual([
+ {
+ deviceId: "__meshchat_default_in__",
+ kind: "audioinput",
+ label: "Default",
+ groupId: "",
+ },
+ ]);
+ expect(wrapper.vm.audioOutputDevices).toEqual([
+ {
+ deviceId: "__meshchat_default_out__",
+ kind: "audiooutput",
+ label: "Default",
+ groupId: "",
+ },
+ ]);
} finally {
if (mediaDevicesDescriptor) {
Object.defineProperty(navigator, "mediaDevices", mediaDevicesDescriptor);
@@ -346,8 +403,11 @@ describe("CallPage.vue", () => {
};
const sourceDisconnect = vi.fn();
wrapper.vm.audioSourceNode = { disconnect: sourceDisconnect };
- const processorDisconnect = vi.fn();
- wrapper.vm.audioProcessor = { disconnect: processorDisconnect };
+ const workletDisconnect = vi.fn();
+ wrapper.vm.audioWorkletNode = {
+ port: { onmessage: vi.fn() },
+ disconnect: workletDisconnect,
+ };
const stopTrack = vi.fn();
wrapper.vm.audioStream = { getTracks: () => [{ stop: stopTrack }] };
const ctxClose = vi.fn().mockResolvedValue(undefined);
@@ -357,7 +417,7 @@ describe("CallPage.vue", () => {
expect(wsClose).toHaveBeenCalledTimes(1);
expect(sourceDisconnect).toHaveBeenCalledTimes(1);
- expect(processorDisconnect).toHaveBeenCalledTimes(1);
+ expect(workletDisconnect).toHaveBeenCalledTimes(1);
expect(stopTrack).toHaveBeenCalledTimes(1);
expect(ctxClose).toHaveBeenCalledTimes(1);
expect(wrapper.vm.audioWs).toBeNull();

diff --git a/tests/frontend/MapDrawing.test.js b/tests/frontend/MapDrawing.test.js
index eecf10b7..056f46e3 100644
--- a/tests/frontend/MapDrawing.test.js
+++ b/tests/frontend/MapDrawing.test.js
@@ -1,4 +1,4 @@
-import { mount } from "@vue/test-utils";
+import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
import MapPage from "@/components/map/MapPage.vue";
@@ -28,6 +28,7 @@ vi.mock("ol/Map", () => ({
on: vi.fn(),
un: vi.fn(),
addLayer: vi.fn(),
+ addControl: vi.fn(),
removeLayer: vi.fn(),
addInteraction: vi.fn(),
removeInteraction: vi.fn(),
@@ -51,6 +52,8 @@ vi.mock("ol/Map", () => ({
getArray: vi.fn().mockReturnValue([]),
}),
forEachFeatureAtPixel: vi.fn(),
+ getEventPixel: vi.fn().mockReturnValue([0, 0]),
+ getTargetElement: vi.fn().mockReturnValue({ style: {} }),
setTarget: vi.fn(),
updateSize: vi.fn(),
getViewport: vi.fn().mockReturnValue({
@@ -60,6 +63,11 @@ vi.mock("ol/Map", () => ({
};
}),
}));
+vi.mock("ol/control/ScaleLine", () => ({
+ default: vi.fn().mockImplementation(function () {
+ return {};
+ }),
+}));
vi.mock("ol/View", () => ({ default: vi.fn(function () {}) }));
vi.mock("ol/layer/Tile", () => ({ default: vi.fn(function () {}) }));
@@ -360,4 +368,62 @@ describe("MapPage.vue - Drawing and Measurement Tools", () => {
expect(wrapper.text()).toContain("Saved Layer 1");
});
+
+ it("renders bearing toolbar controls", async () => {
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('button[title="map.tool_bearing"]').exists()).toBe(true);
+ expect(wrapper.find('button[title="map.tool_bearing_from_here"]').exists()).toBe(true);
+ });
+
+ it("toggles bearing mode", async () => {
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ await wrapper.vm.$nextTick();
+ const bearingBtn = wrapper.find('button[title="map.tool_bearing"]');
+ await bearingBtn.trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isBearingMode).toBe(true);
+ await bearingBtn.trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isBearingMode).toBe(false);
+ });
+
+ it("entering bearing mode turns off measurement mode", async () => {
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ await wrapper.vm.$nextTick();
+ await wrapper.find('button[title="map.tool_measure"]').trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isMeasuring).toBe(true);
+ await wrapper.find('button[title="map.tool_bearing"]').trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isMeasuring).toBe(false);
+ expect(wrapper.vm.isBearingMode).toBe(true);
+ });
+
+ it("bearing from here sets GPS anchor when geolocation succeeds", async () => {
+ const origNav = global.navigator;
+ try {
+ global.navigator = {
+ geolocation: {
+ getCurrentPosition: vi.fn((success) => success({ coords: { longitude: -0.1, latitude: 51.5 } })),
+ },
+ };
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ await wrapper.vm.$nextTick();
+ await wrapper.find('button[title="map.tool_bearing_from_here"]').trigger("click");
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isBearingMode).toBe(true);
+ expect(wrapper.vm.bearingFromGps).toBe(true);
+ expect(wrapper.vm.bearingGpsMapCoord).toEqual([-0.1, 51.5]);
+ } finally {
+ global.navigator = origNav;
+ }
+ });
});

diff --git a/tests/frontend/MapPage.test.js b/tests/frontend/MapPage.test.js
index 5d357e5b..51549676 100644
--- a/tests/frontend/MapPage.test.js
+++ b/tests/frontend/MapPage.test.js
@@ -1,4 +1,4 @@
-import { mount } from "@vue/test-utils";
+import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
// Mock TileCache BEFORE importing MapPage
@@ -26,6 +26,7 @@ vi.mock("ol/Map", () => ({
return {
on: vi.fn(),
addLayer: vi.fn(),
+ addControl: vi.fn(),
addInteraction: vi.fn(),
addOverlay: vi.fn(),
removeInteraction: vi.fn(),
@@ -90,6 +91,11 @@ vi.mock("ol/proj", () => ({
vi.mock("ol/control", () => ({
defaults: vi.fn().mockReturnValue([]),
}));
+vi.mock("ol/control/ScaleLine", () => ({
+ default: vi.fn().mockImplementation(function () {
+ return {};
+ }),
+}));
vi.mock("ol/interaction/Draw", () => ({
default: vi.fn().mockImplementation(function () {
return { on: vi.fn(), setActive: vi.fn() };
@@ -306,6 +312,40 @@ describe("MapPage.vue", () => {
delete global.fetch;
});
+ it("uses stacked MBTiles only for default or known online tile presets, not local tile URLs", async () => {
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.offlineEnabled = true;
+ wrapper.vm.tileServerUrl = "http://192.168.1.10/tiles/{z}/{x}/{y}.png";
+ expect(wrapper.vm.usesOfflineMbtilesRaster()).toBe(false);
+ wrapper.vm.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+ expect(wrapper.vm.usesOfflineMbtilesRaster()).toBe(true);
+ });
+
+ it("search uses custom local nominatim base URL in request", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve([]),
+ });
+
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.nominatimApiUrl = "http://127.0.0.1:18181/nominatim/";
+ const searchInput = wrapper.find('input[type="text"]');
+ await searchInput.trigger("focus");
+ await searchInput.setValue("hq");
+ await searchInput.trigger("keydown.enter");
+ await flushPromises();
+
+ expect(global.fetch).toHaveBeenCalled();
+ const calledUrl = String(global.fetch.mock.calls[0][0]);
+ expect(calledUrl).toContain("127.0.0.1:18181");
+ expect(calledUrl).toContain("/search?");
+ expect(calledUrl).toContain(encodeURIComponent("hq"));
+
+ delete global.fetch;
+ });
+
it("toggles export mode", async () => {
const wrapper = mountMapPage();
await wrapper.vm.$nextTick();
@@ -318,6 +358,44 @@ describe("MapPage.vue", () => {
}
});
+ it("serializeFeatures drops bearing preview features", async () => {
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ const preview = {
+ get: (k) => (k === "bearingPreview" ? true : undefined),
+ };
+ const normal = {
+ get: () => undefined,
+ clone: () => ({
+ unset: vi.fn(),
+ getGeometry: () => null,
+ setGeometry: vi.fn(),
+ }),
+ getStyle: () => null,
+ };
+ const out = wrapper.vm.serializeFeatures([preview, normal]);
+ expect(out).toHaveLength(1);
+ });
+
+ it("toggleBearingMode enables and disables bearing state with a stub map", async () => {
+ const wrapper = mountMapPage();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.map = {
+ addOverlay: vi.fn(),
+ removeOverlay: vi.fn(),
+ un: vi.fn(),
+ };
+ wrapper.vm.select = { setActive: vi.fn(), getFeatures: () => ({ clear: vi.fn(), push: vi.fn() }) };
+ wrapper.vm.translate = { setActive: vi.fn() };
+ wrapper.vm.modify = { setActive: vi.fn() };
+ wrapper.vm.toggleBearingMode();
+ expect(wrapper.vm.isBearingMode).toBe(true);
+ expect(wrapper.vm.drawType).toBe("Bearing");
+ wrapper.vm.toggleBearingMode();
+ expect(wrapper.vm.isBearingMode).toBe(false);
+ expect(wrapper.vm.select.setActive).toHaveBeenCalledWith(true);
+ });
+
it("handles a large number of search results with overflow", async () => {
const manyResults = Array.from({ length: 100 }, (_, i) => ({
place_id: i,

diff --git a/tests/frontend/MapPageClustering.test.js b/tests/frontend/MapPageClustering.test.js
index 65ae9196..082f3679 100644
--- a/tests/frontend/MapPageClustering.test.js
+++ b/tests/frontend/MapPageClustering.test.js
@@ -34,6 +34,7 @@ const viewMock = {
const mapMock = {
on: vi.fn(),
addLayer: vi.fn(),
+ addControl: vi.fn(),
addInteraction: vi.fn(),
addOverlay: vi.fn(),
removeInteraction: vi.fn(),

diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js
index d2eb4d65..2dd64604 100644
--- a/tests/frontend/MicronParser.test.js
+++ b/tests/frontend/MicronParser.test.js
@@ -495,7 +495,7 @@ describe("MicronParser.js", () => {
const markup = "\n".repeat(10000);
const start = Date.now();
parser.convertMicronToHtml(markup);
- expect(Date.now() - start).toBeLessThan(1500);
+ expect(Date.now() - start).toBeLessThan(2500);
});
it("handles rapid format toggle (open/close/open/close) quickly", () => {

diff --git a/tests/frontend/MicrophoneRecorder.test.js b/tests/frontend/MicrophoneRecorder.test.js
index 2fb20f45..0f5421b4 100644
--- a/tests/frontend/MicrophoneRecorder.test.js
+++ b/tests/frontend/MicrophoneRecorder.test.js
@@ -2,8 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import MicrophoneRecorder from "@/js/MicrophoneRecorder";
function installFakeAudioContext({ sampleRate = 48000 } = {}) {
- const processorNode = {
- onaudioprocess: null,
+ const workletNode = {
+ port: {
+ onmessage: null,
+ },
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ };
+ const gainNode = {
+ gain: { value: 0 },
connect: vi.fn(),
disconnect: vi.fn(),
};
@@ -17,15 +24,23 @@ function installFakeAudioContext({ sampleRate = 48000 } = {}) {
resume: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn(() => sourceNode),
- createScriptProcessor: vi.fn(() => processorNode),
+ createGain: vi.fn(() => gainNode),
+ audioWorklet: {
+ addModule: vi.fn().mockResolvedValue(undefined),
+ },
};
const original = globalThis.AudioContext;
+ const originalAWN = globalThis.AudioWorkletNode;
globalThis.AudioContext = vi.fn(function FakeAudioContext() {
return ctx;
});
+ globalThis.AudioWorkletNode = vi.fn(function FakeAudioWorkletNode() {
+ return workletNode;
+ });
return {
ctx,
- processorNode,
+ workletNode,
+ gainNode,
sourceNode,
restore() {
if (typeof original === "undefined") {
@@ -33,6 +48,11 @@ function installFakeAudioContext({ sampleRate = 48000 } = {}) {
} else {
globalThis.AudioContext = original;
}
+ if (typeof originalAWN === "undefined") {
+ Reflect.deleteProperty(globalThis, "AudioWorkletNode");
+ } else {
+ globalThis.AudioWorkletNode = originalAWN;
+ }
},
};
}
@@ -112,17 +132,26 @@ describe("MicrophoneRecorder", () => {
await expect(recorder.start()).resolves.toBe(true);
expect(audio.ctx.createMediaStreamSource).toHaveBeenCalledTimes(1);
- expect(audio.ctx.createScriptProcessor).toHaveBeenCalledWith(4096, 1, 1);
- expect(audio.sourceNode.connect).toHaveBeenCalledWith(audio.processorNode);
- expect(audio.processorNode.connect).toHaveBeenCalledWith(audio.ctx.destination);
+ expect(audio.ctx.audioWorklet.addModule).toHaveBeenCalledTimes(1);
+ expect(globalThis.AudioWorkletNode).toHaveBeenCalledWith(
+ audio.ctx,
+ "microphone-pcm-float",
+ expect.objectContaining({
+ numberOfInputs: 1,
+ numberOfOutputs: 1,
+ channelCount: 1,
+ })
+ );
+ expect(audio.sourceNode.connect).toHaveBeenCalledWith(audio.workletNode);
+ expect(audio.workletNode.connect).toHaveBeenCalledWith(audio.gainNode);
+ expect(audio.gainNode.connect).toHaveBeenCalledWith(audio.ctx.destination);
const frame = new Float32Array(1024);
for (let i = 0; i < frame.length; i++) {
frame[i] = Math.sin((i / frame.length) * Math.PI * 2) * 0.5;
}
- audio.processorNode.onaudioprocess({
- inputBuffer: { getChannelData: () => frame },
- });
+ expect(typeof audio.workletNode.port.onmessage).toBe("function");
+ audio.workletNode.port.onmessage({ data: frame.buffer.slice(0) });
const blob = await recorder.stop();
expect(blob).toBeInstanceOf(Blob);

diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index 76dabaf2..b122f29d 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -24,6 +24,7 @@ describe("ToolsPage.vue", () => {
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
{ path: "/debug-logs", name: "debug-logs", component: { template: "div" } },
{ path: "/mesh-server", name: "mesh-server", component: { template: "div" } },
+ { path: "/tools/repository-server", name: "repository-server", component: { template: "div" } },
{ path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } },
],
});
@@ -54,7 +55,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
- expect(toolRows.length).toBe(20);
+ expect(toolRows.length).toBe(21);
});
it("filters tools based on search query", async () => {
@@ -79,6 +80,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
- expect(wrapper.vm.filteredTools.length).toBe(20);
+ expect(wrapper.vm.filteredTools.length).toBe(21);
});
});

diff --git a/tests/frontend/mapComponents.test.js b/tests/frontend/mapComponents.test.js
new file mode 100644
index 00000000..420a5e11
--- /dev/null
+++ b/tests/frontend/mapComponents.test.js
@@ -0,0 +1,351 @@
+// SPDX-License-Identifier: 0BSD
+
+import { mount } from "@vue/test-utils";
+import { describe, it, expect } from "vitest";
+import MapDrawingToolbar from "@/components/map/internal/MapDrawingToolbar.vue";
+import MapBearingInstructions from "@/components/map/internal/MapBearingInstructions.vue";
+import MapSearchBar from "@/components/map/internal/MapSearchBar.vue";
+import MapExportInstructions from "@/components/map/internal/MapExportInstructions.vue";
+import MapNoMapWarning from "@/components/map/internal/MapNoMapWarning.vue";
+import MapLoadingOverlay from "@/components/map/internal/MapLoadingOverlay.vue";
+import MapExportConfigPanel from "@/components/map/internal/MapExportConfigPanel.vue";
+import MapExportProgressPanel from "@/components/map/internal/MapExportProgressPanel.vue";
+import MapClusterPanel from "@/components/map/internal/MapClusterPanel.vue";
+import MapMarkerPanel from "@/components/map/internal/MapMarkerPanel.vue";
+import MapVectorExchangePanel from "@/components/map/internal/MapVectorExchangePanel.vue";
+
+const DRAWING_TOOLS = [
+ { type: "Select", icon: "cursor-default" },
+ { type: "Point", icon: "map-marker-plus" },
+ { type: "LineString", icon: "vector-line" },
+ { type: "Polygon", icon: "vector-polygon" },
+ { type: "Circle", icon: "circle-outline" },
+ { type: "Export", icon: "crop-free" },
+];
+
+function t(key) {
+ return key;
+}
+
+describe("MapDrawingToolbar", () => {
+ it("emits toggle-bearing and bearing-from-here", async () => {
+ const wrapper = mount(MapDrawingToolbar, {
+ props: {
+ tools: DRAWING_TOOLS,
+ drawType: null,
+ measuring: false,
+ bearingMode: false,
+ bearingFromGps: false,
+ exportMode: false,
+ selectedFeature: null,
+ },
+ global: { mocks: { $t: t } },
+ });
+ await wrapper.find('button[title="map.tool_bearing"]').trigger("click");
+ expect(wrapper.emitted("toggle-bearing")).toHaveLength(1);
+ await wrapper.find('button[title="map.tool_bearing_from_here"]').trigger("click");
+ expect(wrapper.emitted("bearing-from-here")).toHaveLength(1);
+ });
+
+ it("applies bearing highlight classes when bearingMode is true", () => {
+ const wrapper = mount(MapDrawingToolbar, {
+ props: {
+ tools: DRAWING_TOOLS,
+ bearingMode: true,
+ bearingFromGps: false,
+ measuring: false,
+ exportMode: false,
+ selectedFeature: null,
+ },
+ global: { mocks: { $t: t } },
+ });
+ const bearingBtn = wrapper.find('button[title="map.tool_bearing"]');
+ expect(bearingBtn.classes().some((c) => c.includes("teal"))).toBe(true);
+ });
+
+ it("emits toggle-measure and toggle-draw", async () => {
+ const wrapper = mount(MapDrawingToolbar, {
+ props: {
+ tools: DRAWING_TOOLS,
+ bearingMode: false,
+ measuring: false,
+ exportMode: false,
+ selectedFeature: null,
+ },
+ global: { mocks: { $t: t } },
+ });
+ await wrapper.find('button[title="map.tool_measure"]').trigger("click");
+ expect(wrapper.emitted("toggle-measure")).toHaveLength(1);
+ await wrapper.find('button[title="map.tool_point"]').trigger("click");
+ expect(wrapper.emitted("toggle-draw")).toEqual([["Point"]]);
+ });
+});
+
+describe("MapBearingInstructions", () => {
+ it("shows first-hint copy and emits use-my-location", async () => {
+ const wrapper = mount(MapBearingInstructions, {
+ props: { fromGpsActive: false, awaitingSecondTap: false },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.bearing_hint_first");
+ await wrapper.find("button").trigger("click");
+ expect(wrapper.emitted("use-my-location")).toHaveLength(1);
+ });
+
+ it("shows destination hint when fromGpsActive", () => {
+ const wrapper = mount(MapBearingInstructions, {
+ props: { fromGpsActive: true, awaitingSecondTap: true },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.bearing_hint_destination");
+ expect(wrapper.find("button").exists()).toBe(false);
+ });
+
+ it("shows second-tap hint for two-point mode", () => {
+ const wrapper = mount(MapBearingInstructions, {
+ props: { fromGpsActive: false, awaitingSecondTap: true },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.bearing_hint_second");
+ });
+});
+
+describe("MapSearchBar", () => {
+ it("updates modelValue, search, clear, and select", async () => {
+ const wrapper = mount(MapSearchBar, {
+ props: {
+ modelValue: "",
+ results: [{ display_name: "Somewhere", type: "city" }],
+ error: null,
+ searching: false,
+ showResults: true,
+ },
+ global: { mocks: { $t: t } },
+ });
+ const input = wrapper.find("input");
+ await input.setValue("q");
+ expect(wrapper.emitted("update:modelValue")).toEqual([["q"]]);
+ await wrapper.setProps({ modelValue: "q" });
+ await input.trigger("keydown.enter");
+ expect(wrapper.emitted("search")).toHaveLength(1);
+ await wrapper.findAll("button").at(0).trigger("click");
+ expect(wrapper.emitted("clear")).toHaveLength(1);
+ });
+
+ it("renders error state when error is set", () => {
+ const wrapper = mount(MapSearchBar, {
+ props: {
+ modelValue: "x",
+ results: [],
+ error: "map.search_error",
+ searching: false,
+ showResults: true,
+ },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.search_error");
+ });
+
+ it("emits select when a result row is clicked", async () => {
+ const row = { display_name: "A", type: "town" };
+ const wrapper = mount(MapSearchBar, {
+ props: {
+ modelValue: "a",
+ results: [row],
+ showResults: true,
+ },
+ global: { mocks: { $t: t } },
+ });
+ await wrapper.find("button.w-full").trigger("click");
+ expect(wrapper.emitted("select")).toEqual([[row]]);
+ });
+});
+
+describe("MapExportInstructions", () => {
+ it("emits select-preset for a preset button", async () => {
+ const presets = [{ id: "europe" }];
+ const wrapper = mount(MapExportInstructions, {
+ props: { presets },
+ global: { mocks: { $t: t } },
+ });
+ await wrapper.find("button").trigger("click");
+ expect(wrapper.emitted("select-preset")).toEqual([[presets[0]]]);
+ });
+});
+
+describe("MapNoMapWarning", () => {
+ it("emits upload", async () => {
+ const wrapper = mount(MapNoMapWarning, {
+ global: { mocks: { $t: t } },
+ });
+ await wrapper.find("button").trigger("click");
+ expect(wrapper.emitted("upload")).toHaveLength(1);
+ });
+});
+
+describe("MapLoadingOverlay", () => {
+ it("shows custom message when provided", () => {
+ const wrapper = mount(MapLoadingOverlay, {
+ props: { message: "custom" },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("custom");
+ });
+
+ it("falls back to map.uploading when message is null", () => {
+ const wrapper = mount(MapLoadingOverlay, {
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.uploading");
+ });
+});
+
+describe("MapExportConfigPanel", () => {
+ it("emits update:minZoom and start", async () => {
+ const wrapper = mount(MapExportConfigPanel, {
+ props: { minZoom: 5, maxZoom: 10, estimatedTiles: 100, exporting: false, tileLimitExceeded: false },
+ global: { mocks: { $t: t } },
+ });
+ const inputs = wrapper.findAll('input[type="number"]');
+ await inputs.at(0).setValue(6);
+ expect(wrapper.emitted("update:minZoom")).toEqual([[6]]);
+ await wrapper
+ .findAll("button")
+ .filter((b) => b.text().includes("map.start_export"))
+ .at(0)
+ .trigger("click");
+ expect(wrapper.emitted("start")).toHaveLength(1);
+ });
+
+ it("disables start when tileLimitExceeded", () => {
+ const wrapper = mount(MapExportConfigPanel, {
+ props: {
+ minZoom: 0,
+ maxZoom: 20,
+ estimatedTiles: 9999999,
+ tileLimitExceeded: true,
+ },
+ global: { mocks: { $t: t } },
+ });
+ const startBtn = wrapper
+ .findAll("button")
+ .filter((b) => b.text().includes("map.start_export"))
+ .at(0);
+ expect(startBtn.attributes("disabled")).toBeDefined();
+ });
+});
+
+describe("MapExportProgressPanel", () => {
+ it("shows progress for running export", () => {
+ const wrapper = mount(MapExportProgressPanel, {
+ props: {
+ status: { status: "running", progress: 40, current: 4, total: 10 },
+ exportId: "e1",
+ },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.exporting");
+ expect(wrapper.text()).toContain("40%");
+ });
+
+ it("emits dismiss when completed", async () => {
+ const wrapper = mount(MapExportProgressPanel, {
+ props: {
+ status: { status: "completed", progress: 100, current: 10, total: 10 },
+ exportId: "x",
+ },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("map.download_ready");
+ const link = wrapper.find('a[href="/api/v1/map/export/x/download"]');
+ expect(link.exists()).toBe(true);
+ await wrapper.find("button.text-gray-400").trigger("click");
+ expect(wrapper.emitted("dismiss")).toHaveLength(1);
+ });
+
+ it("emits cancel while running", async () => {
+ const wrapper = mount(MapExportProgressPanel, {
+ props: {
+ status: { status: "running", progress: 1, current: 1, total: 99 },
+ },
+ global: { mocks: { $t: t } },
+ });
+ await wrapper.find("button.text-red-500").trigger("click");
+ expect(wrapper.emitted("cancel")).toHaveLength(1);
+ });
+});
+
+describe("MapClusterPanel", () => {
+ it("emits close and select", async () => {
+ const cluster = {
+ count: 2,
+ items: [
+ {
+ kind: "telemetry",
+ label: "Peer",
+ identifier: "abc",
+ peer: { lxmf_user_icon: { icon_name: "account" } },
+ },
+ ],
+ };
+ const wrapper = mount(MapClusterPanel, {
+ props: { cluster },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.text()).toContain("2");
+ await wrapper.find('button[title="Close"]').trigger("click");
+ expect(wrapper.emitted("close")).toHaveLength(1);
+ await wrapper.find("button.w-full").trigger("click");
+ expect(wrapper.emitted("select")).toEqual([[cluster.items[0]]]);
+ });
+});
+
+describe("MapMarkerPanel", () => {
+ it("renders discovered node and emits close", async () => {
+ const marker = {
+ discovered: {
+ name: "NodeA",
+ latitude: 1.2,
+ longitude: 3.4,
+ interface: "eth0",
+ },
+ };
+ const wrapper = mount(MapMarkerPanel, {
+ props: { marker },
+ global: {
+ mocks: { $t: t },
+ stubs: { MiniChat: { template: "<div class='mini-chat-stub'></div>" } },
+ },
+ });
+ expect(wrapper.text()).toContain("NodeA");
+ expect(wrapper.text()).toContain("1.200000");
+ await wrapper.find("button.text-gray-500").trigger("click");
+ expect(wrapper.emitted("close")).toHaveLength(1);
+ });
+});
+
+describe("MapVectorExchangePanel", () => {
+ it("emits export-geojson when export button is clicked", async () => {
+ const wrapper = mount(MapVectorExchangePanel, {
+ props: { disabled: false, hasFeatures: true },
+ global: { mocks: { $t: t } },
+ });
+ const exportBtn = wrapper
+ .findAll("button")
+ .filter((b) => b.text().includes("map.vector_export_geojson"))
+ .at(0);
+ await exportBtn.trigger("click");
+ expect(wrapper.emitted("export-geojson")).toHaveLength(1);
+ });
+
+ it("toggle merge checkbox changes mergeImport", async () => {
+ const wrapper = mount(MapVectorExchangePanel, {
+ props: { hasFeatures: false },
+ global: { mocks: { $t: t } },
+ });
+ expect(wrapper.vm.mergeImport).toBe(true);
+ const cb = wrapper.find('input[type="checkbox"]');
+ await cb.setValue(false);
+ expect(wrapper.vm.mergeImport).toBe(false);
+ });
+});

diff --git a/tests/frontend/mapExchange.test.js b/tests/frontend/mapExchange.test.js
new file mode 100644
index 00000000..d800d01f
--- /dev/null
+++ b/tests/frontend/mapExchange.test.js
@@ -0,0 +1,179 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import JSZip from "jszip";
+import Feature from "ol/Feature";
+import Point from "ol/geom/Point";
+import { fromLonLat } from "ol/proj";
+import { readGeoJsonToFeatures, writeFeaturesToGeoJson } from "@/js/mapExchange/geoJsonCodec.js";
+import { readKmlToFeatures, writeFeaturesToKml } from "@/js/mapExchange/kmlCodec.js";
+import { readKmzToFeatures, writeFeaturesToKmzBlob, resolveHrefToZipPath } from "@/js/mapExchange/kmzCodec.js";
+import { getDrawFeatureMetadataPayload } from "@/js/mapExchange/metadataUtils.js";
+import { MCX_ICON_DATA_URL, MCX_STROKE_COLOR } from "@/js/mapExchange/constants.js";
+
+const TINY_PNG =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
+
+function dataUrlToUint8(dataUrl) {
+ const base64 = String(dataUrl).split(",")[1] || "";
+ const binary = atob(base64);
+ const out = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ out[i] = binary.charCodeAt(i);
+ }
+ return out;
+}
+
+describe("mapExchange GeoJSON", () => {
+ it("reads point with mcx_icon_data_url and round-trips properties", () => {
+ const gj = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ properties: { [MCX_ICON_DATA_URL]: TINY_PNG, mcx_icon_scale: 0.5 },
+ geometry: { type: "Point", coordinates: [-122, 37] },
+ },
+ ],
+ };
+ const features = readGeoJsonToFeatures(JSON.stringify(gj), "EPSG:3857");
+ expect(features.length).toBe(1);
+ const out = writeFeaturesToGeoJson(features, "EPSG:3857");
+ const parsed = JSON.parse(out);
+ expect(parsed.features[0].properties[MCX_ICON_DATA_URL]).toContain("data:image/png");
+ });
+
+ it("reads line with mcx stroke properties", () => {
+ const gj = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ properties: { [MCX_STROKE_COLOR]: "#ff0000", mcx_stroke_width: 3 },
+ geometry: {
+ type: "LineString",
+ coordinates: [
+ [0, 0],
+ [1, 1],
+ ],
+ },
+ },
+ ],
+ };
+ const features = readGeoJsonToFeatures(JSON.stringify(gj), "EPSG:3857");
+ expect(features.length).toBe(1);
+ expect(features[0].getGeometry().getType()).toBe("LineString");
+ });
+
+ it("supports simplestyle marker-color on points", () => {
+ const gj = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ properties: { "marker-color": "#00ff00" },
+ geometry: { type: "Point", coordinates: [10, 20] },
+ },
+ ],
+ };
+ const features = readGeoJsonToFeatures(JSON.stringify(gj), "EPSG:3857");
+ expect(features.length).toBe(1);
+ expect(features[0].getStyle()).not.toBeNull();
+ });
+});
+
+describe("mapExchange KML", () => {
+ it("reads placemark with point geometry and name", () => {
+ const kml = `<?xml version="1.0" encoding="UTF-8"?>
+<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>P</name>
+<Style><IconStyle><scale>0.5</scale><Icon><href>${TINY_PNG}</href></Icon></IconStyle></Style>
+<Point><coordinates>-122.1,37.2,0</coordinates></Point>
+</Placemark></Document></kml>`;
+ const features = readKmlToFeatures(kml, "EPSG:3857");
+ expect(features.length).toBeGreaterThanOrEqual(1);
+ const f = features[0];
+ expect(f.getGeometry().getType()).toBe("Point");
+ expect(String(f.get("name") || f.get("Name") || "")).toContain("P");
+ const st = f.getStyle();
+ expect(st && typeof st.getImage === "function" && st.getImage()).toBeTruthy();
+ });
+
+ it("exports and re-reads a minimal point KML", () => {
+ const kml = `<?xml version="1.0" encoding="UTF-8"?>
+<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>X</name>
+<Point><coordinates>5,10,0</coordinates></Point></Placemark></Document></kml>`;
+ const features = readKmlToFeatures(kml, "EPSG:3857");
+ const out = writeFeaturesToKml(features, "EPSG:3857");
+ expect(out.includes("<kml")).toBe(true);
+ expect(out.includes("coordinates")).toBe(true);
+ const again = readKmlToFeatures(out, "EPSG:3857");
+ expect(again.length).toBeGreaterThanOrEqual(1);
+ });
+});
+
+describe("mapExchange KMZ", () => {
+ it("resolves relative href paths inside zip", () => {
+ expect(resolveHrefToZipPath("folder/doc.kml", "files/icon.png")).toBe("folder/files/icon.png");
+ expect(resolveHrefToZipPath("doc.kml", "files/icon.png")).toBe("files/icon.png");
+ expect(resolveHrefToZipPath("doc.kml", "../secret")).toBe(null);
+ });
+
+ it("reads placemark with zip-embedded icon", async () => {
+ const kml = `<?xml version="1.0" encoding="UTF-8"?>
+<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>KMZ Point</name>
+<Style><IconStyle><scale>0.5</scale><Icon><href>files/icon.png</href></Icon></IconStyle></Style>
+<Point><coordinates>-122.1,37.2,0</coordinates></Point>
+</Placemark></Document></kml>`;
+ const zip = new JSZip();
+ zip.file("doc.kml", kml);
+ zip.file("files/icon.png", dataUrlToUint8(TINY_PNG));
+ const buf = await zip.generateAsync({ type: "arraybuffer" });
+ const features = await readKmzToFeatures(buf, "EPSG:3857");
+ expect(features.length).toBeGreaterThanOrEqual(1);
+ const f = features[0];
+ expect(f.getGeometry().getType()).toBe("Point");
+ expect(String(f.get("name") || "")).toContain("KMZ");
+ expect(String(f.get(MCX_ICON_DATA_URL) || "")).toContain("data:image/png");
+ });
+
+ it("exports KMZ with embedded data-URI icon and reads it back", async () => {
+ const kml = `<?xml version="1.0" encoding="UTF-8"?>
+<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Placemark><name>Round</name>
+<Style><IconStyle><Icon><href>${TINY_PNG}</href></Icon></IconStyle></Style>
+<Point><coordinates>10,20,0</coordinates></Point>
+</Placemark></Document></kml>`;
+ const features = readKmlToFeatures(kml, "EPSG:3857");
+ const blob = await writeFeaturesToKmzBlob(features, "EPSG:3857");
+ const ab = await blob.arrayBuffer();
+ const again = await readKmzToFeatures(ab, "EPSG:3857");
+ expect(again.length).toBeGreaterThanOrEqual(1);
+ expect(String(again[0].get("name") || "")).toContain("Round");
+ });
+});
+
+describe("mapExchange metadata", () => {
+ it("builds overlay payload with extended properties", () => {
+ const f = new Feature({
+ geometry: new Point(fromLonLat([-122, 37])),
+ type: "draw",
+ });
+ f.set("name", "Site A");
+ f.set("description", "Plain text");
+ f.set("ref", "Q123");
+ const p = getDrawFeatureMetadataPayload(f);
+ expect(p).not.toBeNull();
+ expect(p.name).toBe("Site A");
+ expect(p.description).toBe("Plain text");
+ expect(p.extended.some((r) => r.key === "ref" && r.value === "Q123")).toBe(true);
+ });
+
+ it("returns geometry_type when no other metadata", () => {
+ const f = new Feature({
+ geometry: new Point(fromLonLat([-122, 37])),
+ type: "draw",
+ });
+ const p = getDrawFeatureMetadataPayload(f);
+ expect(p).not.toBeNull();
+ expect(p.extended.some((r) => r.key === "geometry_type" && r.value === "Point")).toBe(true);
+ });
+});

diff --git a/tests/frontend/mapGeodesy.test.js b/tests/frontend/mapGeodesy.test.js
new file mode 100644
index 00000000..cd7ed117
--- /dev/null
+++ b/tests/frontend/mapGeodesy.test.js
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import { getDistance } from "ol/sphere";
+import {
+ sphericalInitialBearingDeg,
+ rhumbLineMetrics,
+ computeSegmentMetrics,
+ formatLengthPairMeters,
+ buildBearingOverlayHtml,
+ buildBearingLiveTooltipHtml,
+} from "@/js/mapGeodesy.js";
+
+describe("mapGeodesy", () => {
+ it("sphericalInitialBearingDeg: due east on equator", () => {
+ const b = sphericalInitialBearingDeg(0, 0, 1, 0);
+ expect(b).toBeCloseTo(90, 0);
+ });
+
+ it("sphericalInitialBearingDeg: due north along meridian", () => {
+ const b = sphericalInitialBearingDeg(0, 0, 0, 1);
+ expect(b).toBeCloseTo(0, 0);
+ });
+
+ it("computeSegmentMetrics: back azimuth equals initial bearing reversed", () => {
+ const m = computeSegmentMetrics(-0.1276, 51.5074, 2.3522, 48.8566);
+ const rev = computeSegmentMetrics(2.3522, 48.8566, -0.1276, 51.5074);
+ expect(m.forwardAzimuthDeg).toBeCloseTo(rev.backAzimuthDeg, 0);
+ expect(m.backAzimuthDeg).toBeCloseTo(rev.forwardAzimuthDeg, 0);
+ });
+
+ it("computeSegmentMetrics: geodesic distance matches ol/sphere getDistance", () => {
+ const lon1 = -0.1276;
+ const lat1 = 51.5074;
+ const lon2 = 2.3522;
+ const lat2 = 48.8566;
+ const m = computeSegmentMetrics(lon1, lat1, lon2, lat2);
+ const d = getDistance([lon1, lat1], [lon2, lat2]);
+ expect(m.geodesicMeters).toBeCloseTo(d, 4);
+ expect(m.geodesicMeters).toBeGreaterThan(330_000);
+ expect(m.geodesicMeters).toBeLessThan(360_000);
+ });
+
+ it("rhumbLineMetrics: east on equator has bearing 90", () => {
+ const r = rhumbLineMetrics(0, 0, 10, 0);
+ expect(r.bearingDeg).toBeCloseTo(90, 0);
+ expect(r.distanceMeters).toBeCloseTo(getDistance([0, 0], [10, 0]), -2);
+ });
+
+ it("formatLengthPairMeters formats short and long", () => {
+ const s = formatLengthPairMeters(50);
+ expect(s.metric).toMatch(/m$/);
+ const l = formatLengthPairMeters(5000);
+ expect(l.metric).toMatch(/km/);
+ });
+
+ it("buildBearingOverlayHtml escapes and includes labels", () => {
+ const m = computeSegmentMetrics(0, 0, 0.1, 0);
+ const html = buildBearingOverlayHtml(m, (k) =>
+ k === "map.bearing_geodesic" ? "Geodesic<script>evil()</script>" : k
+ );
+ expect(html).not.toContain("<script>");
+ expect(html).toContain("Geodesic");
+ expect(html).toContain("map.bearing_forward");
+ });
+
+ it("buildBearingLiveTooltipHtml produces compact string", () => {
+ const m = computeSegmentMetrics(0, 0, 0, 0.5);
+ const h = buildBearingLiveTooltipHtml(m, (k) => k);
+ expect(h).toContain("map.bearing_geodesic");
+ expect(h).toContain("<br/>");
+ });
+});

diff --git a/tests/frontend/mapLinkUtils.test.js b/tests/frontend/mapLinkUtils.test.js
new file mode 100644
index 00000000..b21befd9
--- /dev/null
+++ b/tests/frontend/mapLinkUtils.test.js
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import {
+ buildMeshchatMapUri,
+ buildWebHashMapUrl,
+ findMapUriInContent,
+ mapLinkKindFromMessage,
+ parseMeshchatMapUri,
+} from "@/js/mapLinkUtils.js";
+
+describe("mapLinkUtils", () => {
+ it("builds and parses meshchatx map URIs", () => {
+ const uri = buildMeshchatMapUri({
+ lat: 1.5,
+ lon: -2.25,
+ zoom: 7,
+ layers: "discovered",
+ label: "Test",
+ });
+ expect(uri.startsWith("meshchatx://map?")).toBe(true);
+ const p = parseMeshchatMapUri(uri);
+ expect(p).not.toBeNull();
+ expect(p.lat).toBeCloseTo(1.5);
+ expect(p.lon).toBeCloseTo(-2.25);
+ expect(p.zoom).toBe(7);
+ expect(p.layers).toBe("discovered");
+ expect(p.label).toBe("Test");
+ });
+
+ it("accepts meshchat:// alias", () => {
+ const p = parseMeshchatMapUri("meshchat://map?lat=10&lon=20&z=5");
+ expect(p).not.toBeNull();
+ expect(p.zoom).toBe(5);
+ });
+
+ it("reads z or zoom query", () => {
+ expect(parseMeshchatMapUri("meshchatx://map?lat=0&lon=0&zoom=12").zoom).toBe(12);
+ expect(parseMeshchatMapUri("meshchatx://map?lat=0&lon=0&z=9").zoom).toBe(9);
+ });
+
+ it("finds first map URI in text", () => {
+ const text = "See meshchatx://map?lat=1&lon=2&z=3 end";
+ expect(findMapUriInContent(text)).toBe("meshchatx://map?lat=1&lon=2&z=3");
+ });
+
+ it("classifies ping vs view from message", () => {
+ expect(mapLinkKindFromMessage("MeshChatX map ping: meshchatx://map?lat=0&lon=0&z=3", null)).toBe("ping");
+ expect(mapLinkKindFromMessage("hello", { label: "Ping" })).toBe("ping");
+ expect(mapLinkKindFromMessage("hello", { label: "Here" })).toBe("view");
+ });
+
+ it("buildWebHashMapUrl includes hash route", () => {
+ const u = buildWebHashMapUrl({ lat: 3, lon: 4, zoom: 8, layers: "discovered" });
+ expect(u).toContain("#/map?");
+ expect(u).toContain("lat=3");
+ expect(u).toContain("lon=4");
+ expect(u).toContain("zoom=8");
+ expect(u).toContain("layers=discovered");
+ });
+});

diff --git a/tests/frontend/mapTileNetwork.test.js b/tests/frontend/mapTileNetwork.test.js
new file mode 100644
index 00000000..1f77595e
--- /dev/null
+++ b/tests/frontend/mapTileNetwork.test.js
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ buildNominatimSearchUrl,
+ normalizeHttpBaseUrl,
+ fetchTileBlobWithRetry,
+ fetchJsonWithRetry,
+ TILE_FETCH_TIMEOUT_MS,
+} from "@/js/mapTileNetwork";
+
+describe("mapTileNetwork", () => {
+ describe("normalizeHttpBaseUrl", () => {
+ it("strips trailing slash", () => {
+ expect(normalizeHttpBaseUrl("http://127.0.0.1:8080/nominatim/")).toBe("http://127.0.0.1:8080/nominatim");
+ });
+ });
+
+ describe("buildNominatimSearchUrl", () => {
+ it("builds search URL for public nominatim", () => {
+ const u = buildNominatimSearchUrl("https://nominatim.openstreetmap.org", "Paris", 5);
+ expect(u).toBe("https://nominatim.openstreetmap.org/search?format=json&q=Paris&limit=5&addressdetails=1");
+ });
+
+ it("supports custom local mesh nominatim base", () => {
+ const u = buildNominatimSearchUrl("http://192.168.44.1:3000/", "camp", 10);
+ expect(u.startsWith("http://192.168.44.1:3000/search?")).toBe(true);
+ expect(u).toContain(encodeURIComponent("camp"));
+ });
+
+ it("encodes query parameters safely", () => {
+ const u = buildNominatimSearchUrl("http://localhost/nom", "a&b=c", 10);
+ expect(u).toContain(encodeURIComponent("a&b=c"));
+ expect(u).not.toContain("a&b=c&");
+ });
+ });
+
+ describe("fetchTileBlobWithRetry", () => {
+ it("returns blob on first successful response", async () => {
+ const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ blob: () => Promise.resolve(blob),
+ });
+ const r = await fetchTileBlobWithRetry("https://tile.example/0/0/0.png", {}, { retries: 0 });
+ expect(r.ok).toBe(true);
+ expect(r.blob).toBe(blob);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ delete global.fetch;
+ });
+
+ it("retries after failure then succeeds", async () => {
+ const blob = new Blob([], { type: "image/png" });
+ global.fetch = vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockResolvedValueOnce({
+ ok: true,
+ blob: () => Promise.resolve(blob),
+ });
+ const r = await fetchTileBlobWithRetry(
+ "https://tile.example/1/1/1.png",
+ {},
+ { retries: 2, retryBaseDelayMs: 0 }
+ );
+ expect(r.ok).toBe(true);
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ delete global.fetch;
+ });
+
+ it("returns ok false after exhausting retries", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new TypeError("network"));
+ const r = await fetchTileBlobWithRetry(
+ "https://tile.example/z/x/y.png",
+ {},
+ { retries: 1, retryBaseDelayMs: 0, timeoutMs: TILE_FETCH_TIMEOUT_MS }
+ );
+ expect(r.ok).toBe(false);
+ expect(r.error).toBeDefined();
+ delete global.fetch;
+ });
+ });
+
+ describe("fetchJsonWithRetry", () => {
+ it("returns response on HTTP 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve([]),
+ });
+ const r = await fetchJsonWithRetry("http://127.0.0.1:9999/search?q=x", {}, { retries: 0 });
+ expect(r.ok).toBe(true);
+ expect(r.response.ok).toBe(true);
+ delete global.fetch;
+ });
+
+ it("does not retry on HTTP 404", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ const r = await fetchJsonWithRetry("http://localhost/n/search", {}, { retries: 2, retryBaseDelayMs: 0 });
+ expect(r.ok).toBe(false);
+ expect(r.status).toBe(404);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ delete global.fetch;
+ });
+ });
+});

diff --git a/tests/frontend/telemetryBatteryChartSpec.test.js b/tests/frontend/telemetryBatteryChartSpec.test.js
new file mode 100644
index 00000000..0a8dafbe
--- /dev/null
+++ b/tests/frontend/telemetryBatteryChartSpec.test.js
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import {
+ batteryHistoryFromTelemetryItems,
+ buildTelemetryBatteryChartSpec,
+ interpolateBatteryByTime,
+} from "../../meshchatx/src/frontend/js/telemetryBatteryChartSpec.js";
+
+describe("telemetryBatteryChartSpec", () => {
+ it("buildTelemetryBatteryChartSpec returns null for short history", () => {
+ expect(buildTelemetryBatteryChartSpec([{ x: 1, y: 50 }], "a")).toBeNull();
+ expect(buildTelemetryBatteryChartSpec([], "a")).toBeNull();
+ });
+
+ it("buildTelemetryBatteryChartSpec returns paths and stable ids", () => {
+ const spec = buildTelemetryBatteryChartSpec(
+ [
+ { x: 1000, y: 80 },
+ { x: 2000, y: 60 },
+ { x: 3000, y: 90 },
+ ],
+ "ab12"
+ );
+ expect(spec).not.toBeNull();
+ expect(spec.linePath).toMatch(/^M /);
+ expect(spec.areaPath).toContain("Z");
+ expect(spec.gradientId).toBe("tb-fill-ab12");
+ expect(spec.strokeGradientId).toBe("tb-stroke-ab12");
+ expect(spec.first.x).toBeLessThan(spec.last.x);
+ expect(spec.layout.minX).toBe(1000);
+ expect(spec.layout.maxX).toBe(3000);
+ expect(spec.viewBox).toBe("0 0 100 46");
+ });
+
+ it("interpolateBatteryByTime clamps and interpolates", () => {
+ const h = [
+ { x: 0, y: 0 },
+ { x: 100, y: 100 },
+ ];
+ expect(interpolateBatteryByTime(h, 50).y).toBe(50);
+ expect(interpolateBatteryByTime(h, -1).y).toBe(0);
+ expect(interpolateBatteryByTime(h, 200).y).toBe(100);
+ });
+
+ it("batteryHistoryFromTelemetryItems sorts by timestamp", () => {
+ const items = [
+ {
+ lxmf_message: {
+ timestamp: 300,
+ fields: { telemetry: { battery: { charge_percent: 10 } } },
+ },
+ },
+ {
+ lxmf_message: {
+ timestamp: 100,
+ fields: { telemetry: { battery: { charge_percent: 50 } } },
+ },
+ },
+ ];
+ const h = batteryHistoryFromTelemetryItems(items);
+ expect(h.map((p) => p.x)).toEqual([100, 300]);
+ expect(h.map((p) => p.y)).toEqual([50, 10]);
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────